大概確定了前後台的畫面後,終於可以來做點功能了,今天的目標是建立題目,然後完成後台的CRUD測試。
只要有題目,就可以開始測驗了,試卷是另一種測驗型式,由老師指定題目內容來進行測驗,而一般的測驗則是從題目中亂數選題來測驗。
在我的計畫中,題目之上還有一個題庫做為所有題目的分類,但今天只想先確認題目的CRUD是可以動的,所以題庫的問題我們之後再說。
我們先定義一下何謂題庫:
由於我們在出測驗時,選項不會每次都一樣的順序,因此比較好的做法是把題目和選項分兩個資料表來處理,想法有了,就來建資料表吧。
在Laravel中,可以在建Model時同時建立migration:
php artisan make:model Subject -m
php artisan make:model Option -m
設計欄位:
xxxxx_create_subjects_table
.....略
return new class extends Migration
{
public function up()
{
Schema::create('subjects', function (Blueprint $table) {
$table->id();
$table->unsignedSmallInteger('seq'); //原始題號順序
$table->text('subject');
$table->string('code'); //題庫代碼
$table->unsignedTinyInteger('level'); //題庫級別、甲,乙,丙,單一
$table->unsignedTinyInteger('group'); //題組
$table->boolean('multiple')->default(false);//單複選
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()
->useCurrentOnUpdate();
});
}
public function down()
{
Schema::dropIfExists('subjects');
}
};
xxxxxx_create_options_table
.....略
return new class extends Migration
{
public function up()
{
Schema::create('options', function (Blueprint $table) {
$table->id();
$table->text('option');
$table->unsignedInteger('subject_id');
$table->boolean('ans');
});
}
public function down()
{
Schema::dropIfExists('options');
}
};
順便把Model及Relation做一下:
Subject
.....略
class Subject extends Model
{
use HasFactory;
protected $guarded=[];
protected $hidden=['created_at','updated_at'];
function options()
{
return $this->hasMany(Option::class);
}
}
Option
.....略
class Option extends Model
{
use HasFactory;
protected $guarded=[];
public $timestamps = false; //不使用timestamp的兩個欄位
function subject()
{
return $this->belongsTo(Subject::class);
}
}
執行migrate:
php artisan migrate
好,現在有資料表了,雖然畫面文字是題庫,但目前先拿來測試新增題目,把新增題庫的按鈕改成Inertia的 組件,使用它來幫我們載入表單頁面:
resources\js\Pages\Backstage\Banks.vue
<Link :href="route(bank.create)"
class="inline-block py-2 px-3 border rounded-xl bg-blue-700 text-blue-100 my-4">
新增題庫
</Link>
新增路由,因為新增的表單是靜態的頁面,目前沒有要接收來自後端的資料,所以可以直接在路由定義輸出的頁面,效果等同laravel的 Route::view
routes\web.php
Route::inertia('/backstage/bank/create','Backstage/CreateBank')->name('bank.create');
畫面很醜我知道,我們先確認一下這個流程沒問題,後面有大把大把的時間來慢慢調畫面。
因為我的題庫來源除了自建題庫外,有一大部份是來自於技能檢定中心的學科題庫,這部份的題庫都是有編號的,所以用別人有的就可以了,之後會把題庫獨立成一個功能類別,到時可以改成選單的型式;
而題組的部分和題庫編號一樣,除了號碼,也會有對應的中文,中文的部份我們後面會建一張資料表來做管理,目前先簡單做就好。
題號也是根據原本的題庫有的題號來填寫就可以了,如果是自訂題庫,當然就看自己愛怎麼寫都可以。
最後一個注意的事項是選項存入資料表之前必須先拿到題目的id,所以這個表單雖然是同時送出題目和選項,但是後端的處理順序應該是先儲存題目進資料表,然後拿到題目的id後,再去存選項,並把題目的id帶入,這樣才能確保題目和選項的關聯。
我們使用 Inertia 的 Form 工具來做表單資料的綁定:
resources\js\Pages\Backstage\CreateBank.vue
<script setup>
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import { Head, Link, useForm } from "@inertiajs/inertia-vue3";
//Inertia的表單物件
const form = useForm({
code: "",
level: "",
group: "",
multiple: 0,
seq: 1,
subject: "",
options: ["", "", "", ""],
ans: [false, false, false, false],
});
//表單傳送時改用Inertia的Form組件來傳送,這是一個ajax的傳送
const submit = () => {
form.post(route("bank.store"));
};
</script>
<template>
<Head title="管理中心" />
<AuthenticatedLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">管理中心</h2>
</template>
//.....略過左側選單
<div class="w-5/6 border rounded-xl p-4">
<!--利用Vue的動作修飾詞來中斷表單submit的動作,並觸發自訂的submit函式-->
<form @submit.prevent="submit">
<div>
<label>題庫編號:</label>
<input type="number" name="code" v-model="form.code" class="w-24" />
<label>級別:</label>
<input type="number" name="level" v-model="form.level" class="w-12" />
<label>題組:</label>
<input type="number" name="group" v-model="form.group" class="w-12" />
<input type="radio" name="multiple" v-model="form.multiple" value="0" />單選
<input type="radio" name="multiple" v-model="form.multiple" value="1" />複選
</div>
<div class="my-1">
<label>題號:</label>
<input type="number" name="seq" min="1" v-model="form.seq" />
</div>
<div class="my-1">
<label>題目:</label>
<input type="text" name="subject" v-model="form.subject" class="w-[90%]" />
</div>
<div class="my-1">
<label for="">選項1:</label>
<input type="text" class="w-[90%]" v-model="form.options[0]" />
<input type="checkbox" v-model="form.ans[0]" />
</div>
<div class="my-1">
<label for="">選項2:</label>
<input type="text" class="w-[90%]" v-model="form.options[1]" />
<input type="checkbox" v-model="form.ans[1]" />
</div>
<div class="my-1">
<label for="">選項3:</label>
<input type="text" class="w-[90%]" v-model="form.options[2]" />
<input type="checkbox" v-model="form.ans[2]" />
</div>
<div class="my-1">
<label for="">選項4:</label>
<input type="text" class="w-[90%]" v-model="form.options[3]" />
<input type="checkbox" v-model="form.ans[3]" />
</div>
<button type="submit"
class="border py-2 px-4 bg-blue-700 text-blue-100 rounded-xl my-2">
新增
</button>
</form>
</div>
</AuthenticatedLayout>
</template>
增加一個用來儲存新資料的路由:
Route::post('/backstage/bank',[BackstageController::class,'store'])->name('bank.store');
在Controller中撰寫儲存題庫的動作。
app\Http\Controllers\BackstageController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\Auth;
use App\Models\Subject;
use App\Models\Option;
class BackstageController extends Controller
{
//......略`
function store(Request $request)
{
$subject=new Subject;
$subject->subject=$request->subject;
$subject->seq=$request->seq;
$subject->code=$request->code;
$subject->group=$request->group;
$subject->multiple=$request->multiple;
$subject->save();
foreach($request->options as $key => $opt){
$option=new Option;
$option->option=$opt;
$option->subject_id=$subject->id;
$option->ans=$request->ans[$key];
$option->save();
}
return redirect()->route('backstage.bank');
}
}
測試一下:
subjects有寫入題目
options有寫入選項,同時題目的id及答案的標示也都有
新增成功後,畫面會redirect回題庫列表的頁面,這時我們可以讓所有的題目列出來,但考量到未來題目可能很多,所以我們應該是讓每個題庫編號有一個對應的中文名字,一但新增完回到列表時,應該是顯示題庫編號的列表,及每個題庫中目前共有多少題目的計數。
但是目前先簡單做,就先全部題目列出來就好,並顯示目前的題目總數。
app\Http\Controllers\BackstageController.php
function bankList()
{
$subjects=Subject::all();
$count=Subject::count();
return Inertia::render('Backstage/Banks',
[
'subjects'=>$subjects,
'count'=>Subject::count()
]);
}
resources\js\Pages\Backstage\Banks.vue
<script setup>
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import { Head, Link } from "@inertiajs/inertia-vue3";
const props = defineProps({ subjects: Array, count: Number });
</script>
<template>
<Head title="管理中心" />
<AuthenticatedLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">管理中心</h2>
</template>
//....略過左側選單
<div class="w-5/6 border rounded-xl p-4">
<Link :href="route('bank.create')"
class="inline-block py-2 px-3 border rounded-xl bg-blue-700 text-blue-100 my-4">
新增題庫
</Link>
<div class="w-full px-4 py-2">題目總數:{{ count }}</div>
<div v-for="subject in subjects"
:key="subject.id"
class="w-full border rounded-xl flex p-4 bg-green-400 justify-between">
<div>
{{ subject.seq }}.
{{ subject.subject }}
</div>
<div>編輯 / 刪除</div>
</div>
</div>
</AuthenticatedLayout>
</template>
編輯題目使用的表單和新增題目的表單是一樣的,只是差在有沒有資料而己,現在只是先驗證一下CRUD沒問題,所以我們先直接複製CreateBank.vue來使用,之後會再提到前端的頁面如何做組件的拆分:
先新增兩個路由,一個是用來顯示編輯表單的路由,一個是要更新資料內容的路由。
routes\web.php
Route::get('/backstage/bank/edit/{id}',[BackstageController::class,'edit'])->name('bank.edit');
Route::put('/backstage/bank/{id}',[BackstageController::class,'update'])->name('bank.update');
接著撰寫Controller中對應的動作:
app\Http\Controllers\BackstageController.php
//取出資料後,回傳給前端組件使用
function edit($id)
{
$subject=Subject::find($id);
$options=$subject->options;
return Inertia::render('Backstage/EditBank',
[
'subject'=>$subject,
'options'=>$options
]);
}
//接收表單傳來的資料,並進行資料的更新
function update(Request $request,$id)
{
$subject=Subject::find($id);
$subject->subject=$request->subject;
$subject->seq=$request->seq;
$subject->code=$request->code;
$subject->level=$request->level;
$subject->group=$request->group;
$subject->multiple=$request->multiple;
$subject->save();
//選項採逐筆更新的方式
foreach($request->options as $key => $opt){
$option=Option::find($opt['id']);
$option->option=$opt['option'];
$option->ans=$opt['ans'];
$option->save();
}
return redirect()->route('backstage.bank');
}
在題庫列表中加上編輯的連結行為
<Link :href="route('bank.edit', subject.id)">編輯</Link>
修改編輯表單
resources\js\Pages\Backstage\EditBank.vue
<script setup>
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import { Head, Link, useForm } from "@inertiajs/inertia-vue3";
const props = defineProps({
subject: Object,
options: Array,
});
//Inertia的表單物件
const form = useForm({
code: props.subject.code,
level: props.subject.level,
group: props.subject.group,
multiple: props.subject.multiple,
seq: props.subject.seq,
subject: props.subject.subject,
options: props.options,
});
//表單傳送時改用Inertia的Form組件來傳送,這是一個ajax的傳送
const submit = () => {
form.put(route("bank.update", props.subject.id));
};
</script>
<template>
<Head title="管理中心" />
<AuthenticatedLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">管理中心</h2>
</template>
//.....略過左側選單
<div class="w-5/6 border rounded-xl p-4">
<!--利用Vue的動作修飾詞來中斷表單submit的動作,並觸發自訂的submit函式-->
<form @submit.prevent="submit">
<div>
<label>題庫編號:</label>
<input type="number" name="code" v-model="form.code" class="w-24" />
<label>級別:</label>
<input type="number" name="level" v-model="form.level" class="w-12" />
<label>題組:</label>
<input type="number" name="group" v-model="form.group" class="w-12" />
<input type="radio" name="multiple" v-model="form.multiple" value="0" /> 單選
<input type="radio" name="multiple" v-model="form.multiple" value="1" /> 複選
</div>
<div class="my-1">
<label>題號:</label>
<input type="number" name="seq" min="1" v-model="form.seq" />
</div>
<div class="my-1">
<label>題目:</label>
<input type="text" name="subject" v-model="form.subject" class="w-[90%]" />
</div>
<div class="my-1">
<label for="">選項1:</label>
<input type="text" class="w-[90%]" v-model="form.options[0].option" />
<input type="checkbox" v-model="form.options[0].ans" />
</div>
<div class="my-1">
<label for="">選項2:</label>
<input type="text" class="w-[90%]" v-model="form.options[1].option" />
<input type="checkbox" v-model="form.options[1].ans" />
</div>
<div class="my-1">
<label for="">選項3:</label>
<input type="text" class="w-[90%]" v-model="form.options[2].option" />
<input type="checkbox" v-model="form.options[2].ans" />
</div>
<div class="my-1">
<label for="">選項4:</label>
<input type="text" class="w-[90%]" v-model="form.options[3].option" />
<input type="checkbox" v-model="form.options[3].ans" />
</div>
<button type="submit"
class="border py-2 px-4 bg-blue-700 text-blue-100 rounded-xl my-2">
修改
</button>
</form>
</div>
</AuthenticatedLayout>
</template>
刪除是個好議題,一般有分為軟刪除和硬刪除,電商或金流相關的應用會優先選擇軟刪除,儘可能保留所有的資料,但我這個應用沒那麼高大上,所以我們選擇硬刪除就可以了。
先建立路由
routes\web.php
Route::delete('/backstage/bank/{id}',[BackstageController::class,'destroy'])->name('bank.destroy');
接著撰寫Controller中對應的動作:
app\Http\Controllers\BackstageController.php
function destroy($id)
{
$subject=Subject::find($id);
$options=$subject->options;
$subject->delete();
$options->map(function($opt){
$opt->delete();
});
}
在題庫列表中加上刪除的連結行為,method要和路由設定的delete一致
resources\js\Pages\Backstage\Banks.vue
<Link :href="route('bank.destroy', subject.id)" method="delete" as="button">
刪除
</Link>
這樣我們就完成了一套CRUD的簡單測試了,對Laravel和Vue的使用也更熟悉了,距離成為一個網頁全端工程師也不遠了...
不,還很遠。。。
我目前為止都是很草莽,很粗魯的先儘快讓畫面和資料能動起來,model的關聯也只是小測一下,這一切都還只是正式開工的前置作業而已,我單純想先驗證一些開發的套路是順暢的,會不會有什麼還沒想到的。
所以很明顯的在這過程中我們留下了不少技術債和缺失,如果不趕快撥亂反正,雖然依舊可以完成專案,但會有一堆重覆的程式碼和日後難以改動的相互依賴關係存在,造成牽一髮動全身的狀況,簡單陳列幾個項目:
BackstageController
應該只負責後台首頁有那些功能要出現就好,它不應該去操作每一項功能中的資料,比如我們現在在其中放了banks、quizzes、tests、groups的四個list函式,而這四個功能都還有CRUD相關的至少五個函式要管理,全部寫在一個class的話,在命名和維護上都會是一場災難,同時將來要在後台新增功能的話,那又要增加一整組至少七個函式,這個 Cotroller 會變得很巨大。Controller
中直接引入 Model 來操作雖然很直覺,但是一但資料的邏輯變複雜時,Controller 也會變得肥大,比如我們在把題目的選項傳給前端 Vue 時,必須先把在資料表中以字串型式儲存的布林值 1 或 0 轉成前端可以使用的布林值 true 或 false,當這樣的動作變多時,一個Controller中的函式除了要驗證參數,控制資料進出之外,還要負責資料的商業邏輯,一個函式可能都要上百行的程式碼,閱讀和維護或擴充都變得不容易。我們到目前為此已經驗證了從 頁面 -> 路由 -> 控制器 -> 資料(model),一整套基礎的應用是沒問題的,也抓到一些固定的套路來開發之後的應用;所以我們不用擔心功能能不能完成的問題了,那麼下一步,我們先來處理一下以上的技術債問題,讓之後的開發可以更有規範一些。
感謝您寫的文章,我新手自學 laravel 對我有相當大的幫助
因為可能時空背景不一樣,所以有些東西可以作個修正
例如:Subject Models
class Subject extends Model
{
use HasFactory;
protected $guarded=[];
protected $hidden=['created_at','updated_at'];
function options()
{
return $this->hasMany(Option::class);
}
}
感謝指正..
應該是漏打了~~~